chore: update Node.js to 24#2186
Merged
Merged
Conversation
Upgrade all CI workflow and Dockerfile Node.js version references to Node 24 (LTS). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Contributor
📦 Build Report🤖 Android
Build Status: ✅ Success 🍏 iOS
🔗 Workflow Run: View logs 🔄 Updated: 2026-05-31 19:46:31 UTC |
kuruk-mm
approved these changes
Jun 1, 2026
kuruk-mm
added a commit
that referenced
this pull request
Jun 4, 2026
* fix: show player hash in tagging autocomplete suggestions (#2080)
* fix: HIDE_AVATARS should not disable passport interaction (#2096)
#2044 set `passport_disabled = true` inside the HIDE_AVATARS branch,
conflating it with the separate DISABLE_PASSPORT modifier. The actual
click-blocking behavior is already handled by disabling the click area
collider, so the flag was both redundant and semantically wrong.
Keeps the click-area disable from #2044 (so invisible avatars still
can't be clicked) and leaves `passport_disabled` exclusive to the
DISABLE_PASSPORT modifier.
* perf(textures): pixel-budget resize with hard 4K dimension cap (#2078)
Rewrite `resize_image` to enforce two independent limits: a total-pixel budget
(`max_size * max_size`) and a per-dimension cap (`MAX_TEXTURE_DIMENSION = 4096`,
safely under common mobile / Quest `MAX_TEXTURE_SIZE`). The old logic only clamped
the larger side against `max_size`, which let extreme aspect ratios still allocate
multi-megapixel textures and let any single dimension exceed GPU limits.
Aspect ratio is preserved by picking the smaller of the two required scale factors
and flooring the result so the new dimensions stay within both bounds.
* feat: implement border properties for Scene UI (#2049)
* ci(bump-version): also bump iOS short_version minor (#2102)
* refactor: reorganize UI components using Atomic Design (#1876) (#2021)
* refactor: reorganize UI components using Atomic Design (#1876)
Implements issue #1876. Reorganize the ~158 .gd / 141 .tscn files under
godot/src/ui/ into a five-tier Atomic Design layout. This PR is move-only:
no components were merged, renamed, or had their behavior changed.
New structure:
godot/src/ui/
├── explorer.gd / explorer.tscn # app shell
├── components/
│ ├── atoms/{buttons,controls,images,inputs}/
│ ├── molecules/
│ └── organisms/
├── layouts/ # responsive / safe-area wrappers
└── pages/ # full-screen views
97 leaf directories moved with `git mv` (file history preserved). All
res:// references across .tscn, .tres, .gd, .gdshader, .import, and one
Rust file (lib/src/scene_runner/rpc_calls/handle_restricted_actions.rs)
were rewritten to point at the new locations. Stale Godot caches
(.godot/global_script_class_cache.cfg, .godot/uid_cache.bin) were
regenerated via `--import`.
Top-level ui/color_picker/ and ui/dialogs/ were folded into
components/organisms/. utils/ kept the non-layout helpers (debounced_action,
addition_shader); layout primitives (safe_margin_container, responsive_container,
orientation helpers, figma_margins) moved to the new layouts/ directory.
Two non-UI 3D files (floating_island_walls.gd, invisible_wall.gd) were
intentionally left in place — relocating them is out of scope for this
refactor.
The duplication audit (deferred unifications, misplaced files, dead-code
candidates) is in godot/src/ui/COMPONENT_AUDIT.md. Each row there is a
follow-up PR.
Verification:
- cargo run -- check-gdscript : 299/299 scripts validated, 0 errors
- cargo run -- import-assets : clean
- Zero remaining stale res://src/ui/ paths in tracked sources
* style: gdformat reflow long preload paths after Atomic Design rename
The Atomic Design move (#1876) pushed several `preload("res://...")` calls
past gdformat's column budget. Reflow them across multiple lines.
Pure formatting — no semantic changes.
* style: cargo fmt reflow long preload path
Mirror of the gdformat reflow — the Atomic Design rename pushed the
nft_dialog.tscn path past rustfmt's column budget.
Pure formatting — no semantic changes.
* docs: add full migration map tables to COMPONENT_AUDIT (#1876)
Add a "Migration map" section with one table per tier (Pages, Layouts,
Atoms, Molecules, Organisms) listing every scene/script that moved with
its old path, new path, and a one-line description.
Makes it possible to audit the rename PR row-by-row without grepping
the diff.
* docs: add godot-ui-components skill and REVIEW tier-structure rule
Adds .claude/skills/godot-ui-components/SKILL.md with the decision tree,
naming conventions, and verification checklist for new UI work in
godot/src/ui/. Adds a Tier 2 finding to REVIEW.md so PR reviewers
explicitly reject regressions back to the pre-#1876 flat layout
(new files at bare components/, feature-folder regressions, duplicate
atoms, stale res:// paths, mixed-tier placement).
* bump version v67 and iOS short_version to 1.6 (#2110)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* feat: rework hide UI with granular sub-toggles (#2055)
* feat: rework hide UI with granular sub-toggles for view profile, interactions and player names
* fix: reset _session_hide_player_names on hide UI off, rename nodes to match features, revert unintended tscn changes
* re-order toggle buttons
* fix: dropdown distinguishes tap from scroll gesture on mobile (#2084)
* fix: easy Sentry triage cluster (5 fixes) (#1999)
* fix: easy Sentry triage cluster — 5 root-cause fixes + StreamProtocol filter
Closes #1958, #1972, #1979, #1984, #1985.
- custom_gltf_importer.gd: gate get_additional_data() with
has_additional_data() so missing keys (placeholder_image, base_path,
mappings) don't emit Dictionary engine warnings (Sentry 1RT, 188u).
- Mouse mode calls (global.gd, explorer.gd, navbar.gd, camera.gd):
guard Input.set_mouse_mode with DisplayServer.has_feature(FEATURE_MOUSE)
so mobile no longer logs "Mouse is not supported by this display
server" (Sentry 6E, 122u).
- project_main_loop.gd: add "StreamProtocol" to NOISE_PATTERNS so the
expected RPC stream-close noise from dcl-rpc (yield/dropped on
consumer drop) stops dominating the Sentry quota (Sentry 1B2/1B3, 35u).
- tooltip_label.gd: skip Input.action_press/release in
mobile_on_panel_container_gui_input when action_to_trigger == "ia_any";
ia_any is a virtual any-action with no InputMap entry, so touching
the tooltip on mobile fired "InputMap action 'ia_any' doesn't exist"
(Sentry 1Z9).
- backpack.gd: replace wearable_data[urn] direct access with .get()
+ null guard in _on_wearable_equip / _on_wearable_unequip and the
inner unequip-by-category loop. Profiles can carry malformed URNs
like 'schoolshoes' that aren't in the dict, which triggered
per-frame script errors when the user opened the backpack
(Sentry 1TR).
* fix: keep base_path/mappings branch verbatim — narrow gltf importer fix to placeholder_image only
Docker avatar-image-generation snapshots regressed because the elif/has_additional_data refactor changed behavior in the mappings path (wearable textures lost their URI remap, falling back to defaults). Revert that branch to the original `else: var base_path = ...; if base_path != null:` shape; keep only the placeholder_image guard since that is the actual reported Sentry warning (1RT, #1958).
* fix
* fix(gltf): seed placeholder_image on GLTFState to silence Sentry warning
Every regular GLTF load was triggering custom_gltf_importer.gd's
state.get_additional_data("placeholder_image") against a missing key,
producing a Dictionary engine warning captured by Sentry (issue #1958).
Previous attempts to guard the read with has_additional_data() were
reverted because they regressed the Docker avatar-image-generation path.
Fix at the source: pre-seed placeholder_image with nil alongside
base_path / mappings. The preflight read now hits an existing key,
the falsy nil keeps the else branch behavior unchanged, and the
external avatar-image-generation tooling that wants placeholder mode
still overwrites with a truthy value.
* feat: add AMT_HIDE_NAMETAGS modifier to AvatarModifierArea (#2079)
* feat: add AMT_HIDE_NAMETAGS modifier to AvatarModifierArea
* chore: bump protocol URL to protocol-squad branch build
* chore: initialize screen_inset_area to None on PbUiCanvasInformation
Required after protocol bump pulls in protocol-squad commit 767845e which
added the optional screen_inset_area field. Actual safe-area wiring is a
follow-up.
* style: wrap should_hide expression to satisfy gdformat
* feat: populate screen_inset_area on PbUiCanvasInformation (#2148)
Mirror the interactable_area BorderRect into screen_inset_area so scenes
can read system-inset info instead of receiving None. The two values are
the same today; we can decouple them later if hardware insets diverge
from the explorer's safe area.
* feat(ui): Scene UI scroll (UiScroll) with touch drag + Unity-parity layout (#2119)
* joystick input wip
* small fixes
* feat(mobile): full-screen camera-input catcher + Scene UI input arbitration
Symmetric to the joystick refactor: route mobile camera drag, pinch, and
cinematic ia_pointer emission through a single full-screen Control with
MOUSE_FILTER_STOP at the lowest input priority under %UI. Godot's native
gui_input + tree-order arbitration now handles "is UI on top?" for camera
input the same way it does for the joystick, removing the manual hit-tests
in player_mobile_input.gd and the orphaned HUD hit-test chain.
- Add MobileCameraInput Control (godot/src/logic/player/mobile_camera_input.gd)
as child index 0 of %UI. Owns drag/pinch/cinematic-pointer.
- Reorder %UI children so SceneUIContainer sits AFTER SafeMarginContainerHUD:
Godot's reverse-tree-order walk now reaches Scene UI's STOP Controls before
the joystick's ActiveArea, so interactive scene UI (UiBackground+PointerEvents)
blocks the joystick instead of losing to it. Visual order is unchanged --
Scene UI's z_index = CANVAS_ITEM_Z_MIN + 1000 keeps it rendered behind HUD.
- Set focus_mode = None on Button_Camera so tapping it no longer captures
focus and silently breaks Input.action_press until another control re-grabs.
- Delete the now-unreachable HUD hit-test chain: chat_panel.is_interactive_area_at,
chat.is_interactive_area_at, chatbar.is_point_inside,
notifications.is_point_over_notification.
- Inline the camera-mode listener into Player; PlayerMobileInput's only remaining
job after the catcher was forwarding Global.camera_mode_set, which is now
identical to PlayerDesktopInput's. Delete player_mobile_input.gd and drop the
duplicate handler from player_desktop_input.gd.
- Remove _on_ui_root_gui_input from explorer.gd; the catcher subsumes its
cinematic ia_pointer flow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ui blocks raycast
* fix import error
* minimal implementation
* improving scrollbars
* refactor(mobile): unified joystick input via gui_input (#2083)
* joystick input wip
* small fixes
* feat(mobile): full-screen camera-input catcher + Scene UI input arbitration
Symmetric to the joystick refactor: route mobile camera drag, pinch, and
cinematic ia_pointer emission through a single full-screen Control with
MOUSE_FILTER_STOP at the lowest input priority under %UI. Godot's native
gui_input + tree-order arbitration now handles "is UI on top?" for camera
input the same way it does for the joystick, removing the manual hit-tests
in player_mobile_input.gd and the orphaned HUD hit-test chain.
- Add MobileCameraInput Control (godot/src/logic/player/mobile_camera_input.gd)
as child index 0 of %UI. Owns drag/pinch/cinematic-pointer.
- Reorder %UI children so SceneUIContainer sits AFTER SafeMarginContainerHUD:
Godot's reverse-tree-order walk now reaches Scene UI's STOP Controls before
the joystick's ActiveArea, so interactive scene UI (UiBackground+PointerEvents)
blocks the joystick instead of losing to it. Visual order is unchanged --
Scene UI's z_index = CANVAS_ITEM_Z_MIN + 1000 keeps it rendered behind HUD.
- Set focus_mode = None on Button_Camera so tapping it no longer captures
focus and silently breaks Input.action_press until another control re-grabs.
- Delete the now-unreachable HUD hit-test chain: chat_panel.is_interactive_area_at,
chat.is_interactive_area_at, chatbar.is_point_inside,
notifications.is_point_over_notification.
- Inline the camera-mode listener into Player; PlayerMobileInput's only remaining
job after the catcher was forwarding Global.camera_mode_set, which is now
identical to PlayerDesktopInput's. Delete player_mobile_input.gd and drop the
duplicate handler from player_desktop_input.gd.
- Remove _on_ui_root_gui_input from explorer.gd; the catcher subsumes its
cinematic ia_pointer flow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ui blocks raycast
* fix import error
* reverse ui blocking raycast
* setting Z index for Control_PointerTooltip
* fix: restore ui_blocker_registry removal lost in PR #2083 squash-merge (#2152)
PR #2119 was branched off PR #2083 at an intermediate commit, before the
"reverse ui blocking raycast" cleanup (81f8b059). When PR #2119 was
squash-merged first, it landed the obsolete ui_blocker_registry code on
main. PR #2083 then merged main into its branch and the conflict
resolution reinstated that code, so the final squash-merge of #2083
only carried a one-line tscn change and never deleted the registry.
Manually reapply 81f8b059's deletions (registry module, its callers in
DclUiControl and SceneManager) while preserving PR #2148's
screen_inset_area population.
* fix: use project setting base resolution for UI zoom + add safemargindebug deep link param (#2040)
* fix: use project setting base resolution for UI zoom
Replace the hardcoded 720x720 base in get_max_ui_zoom and apply_ui_zoom
with the values from project.godot (display/window/size/viewport_*),
and reapply on Global.orientation_changed so the scale is recomputed
when the orientation flips. The base axes are swapped when the window
orientation differs from the project base, so a portrait window scales
against a portrait design space instead of collapsing to ~0.45x.
* feat: safemargindebug deep link param with on-screen safe-area overlay
Adds `safemargindebug=true` deep link parameter that toggles a transparent,
top-level CanvasLayer overlay tinting the four unsafe margin strips and
showing window / scaled / base resolution + safe-margin pixel values in a
center HUD. The overlay persists across scene changes, is click-through,
and is only instantiated when the flag is set so there's no memory cost
when off. Three-finger touch toggles show/hide on device.
Why: while iterating on the UI zoom base-resolution math it's hard to
inspect safe-area insets and effective scale on real devices without
attaching a debugger. This makes the values visible in-app.
* test: pin UI design base resolution to 1600x720
Adds an itest that fails fast if `display/window/size/viewport_*` in
project.godot drifts from the values graphic_settings.gd reads via
GraphicSettings.get_base_resolution. An accidental edit would silently
distort the HUD scale on every platform; this surfaces it in CI instead.
* fix(safemargindebug): explicit types in overlay + add diagnostic prints
The `var scaled := Vector2(...) / max(...)` inferred to Variant under the
strict-mode parser because `max()` returns Variant, which cascaded into a
script-load failure and a misleading `Nonexistent function 'new'` error in
Global.set_safe_margin_debug_enable. Switched to `maxf`/`maxi` with
explicit types so the script compiles cleanly. Also adds the missing
.uid file and a few prints to make future loads easier to trace.
* fix(mobile_preview): stop "Default" device clobbering viewport_* to 720x720
The plugin's _apply_settings writes the selected device's dimensions into
display/window/size/viewport_width/height, which is the same key the new
GraphicSettings.get_base_resolution() reads. The "Default" entry was
hardcoded 720x720 in both orientations, so any editor save while Default
was selected persisted 720x720 as the project's design base — and the
safemargindebug overlay surfaced exactly that on device.
Realign Default to 1600x720 (landscape) / 720x1600 (portrait) to match
the project's real design base, remove the now-redundant "Figma Base"
entry (same dimensions), and clamp the persisted last_device index so
users who had Figma Base (index 4) selected don't end up out of range.
* feat: extend AvatarAttach to all 26 anchor points (#2158)
* attachment wip
* fixed rotations
* code cleanup
* feat: add attestation challenge for mobile-bff (Android & iOS) (#2163)
* feat(attestation): client autoload + native plugins for App Attest / Play Integrity
Adds platform-attestation infrastructure end-to-end:
- iOS: DCAppAttestService bindings in dcl_godot_ios native plugin (3
methods + 3 signals) covering key generation, attestKey ceremony, and
per-request assertion generation. DeviceCheck framework wired into
.gdip manifests.
- Android: Play Integrity API binding in dcl-godot-android plugin
(Classic API via setNonce + setRequestHash). The play:integrity:1.4.0
dependency is declared in BOTH plugin/build.gradle.kts AND the
export_plugin.gd::_get_android_dependencies template — exporting
without the second triggers a ClassNotFoundException at runtime.
- GDScript autoload `Attestation` (godot/src/auth/attestation_service.gd)
abstracts both platforms behind async_get_attestation_headers(body),
handles one-time iOS enrollment, persists key_id to user://, and runs
a fire-and-forget startup verdict report to POST /v1/attest/check.
- xtask src/run.rs gains --console and --terminate-existing for iOS
deploys so device logs stream live without buffered tail pipes.
Backend lives in the mobile-bff repo (separate PR). The sign-message
proxy + per-request attestation header injection arrive with the
thirdweb-otp branch — this branch only exercises /v1/attest/check via
the autoload's startup ping.
* refactor(attestation): move under Global with EULA gate + persisted validation
Drops the Attestation autoload entry from project.godot and instantiates
the service as a Node child of Global instead. This pulls attestation
into the standard component lifecycle (init right next to
AnalyticsController) and lets the service self-gate on user consent.
New flow inside the service:
- On _ready, check `user://attest_validated.txt`. If present → no-op:
this install already passed /v1/attest/check at least once, so no
further network traffic.
- Otherwise, poll Global.get_config().terms_and_conditions_version
every 1s (same condition AnalyticsController uses) until EULA is
accepted.
- Once accepted, run validation against POST /v1/attest/check. On
success → write the `validated` marker and exit the loop. On failure
→ retry with exponential backoff [1, 2, 5, 10, 30]s, indefinite at
the 30s cap.
The pre-EULA gate matters because Play Integrity and App Attest both
phone home to vendor servers, and we shouldn't be making that network
call before the user has consented to terms.
* feat(attestation): re-attest when app version changes
The validated marker now stores `DclGlobal.get_version()` instead of a
constant "ok" placeholder. On boot, `_is_validated()` compares the
stored version against the current one — a mismatch (new release
installed over an old one) treats the install as never-validated, so
the EULA-gated validation runs again.
Motivation:
- Play Integrity may return UNRECOGNIZED_VERSION for a versionCode the
Play Store hasn't propagated yet; we want to detect that on the new
build rather than carrying the old verdict forward.
- App Attest keys can be invalidated when the app is resigned. Forcing
a re-attest on version bump catches that early instead of failing
the first sign-message call.
- Analytics consumers can compute "% installs validated for the current
release" — a stable per-release rate, not a lifetime one.
Also adds diagnostic prints around boot, EULA wait, EULA accept, retry
backoff, and persistence — the iOS device console doesn't surface
GDScript print() output but they help on Android logcat and desktop
runs.
* tested against mobile-bff@bae9c2dd103e5e3e378adee13ec2901c1f59ea95
* add metrics
* fix format
* unify logging/url/analytics
* fix: disable zoom on Create avatar screen (#2164)
* disable zoom on Create avatar screen
* bump version v67 and iOS short_version to 1.6 (#2110)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(comms): route DuplicateIdentity disconnect to modal + sign-out flow (#2085)
The DuplicateIdentity disconnect from LiveKit was silently dropped by
MessageProcessor: the event uses synthetic H160::zero() as the address
to indicate a room-level (not per-peer) signal, which fell through to
process_non_player_message and was discarded. The CommunicationManager
never saw the disconnect_reason, never set block_auto_reconnect, and
never emitted the disconnected signal — so the UI showed no modal and
the scene room kept reconnecting in a 5s loop only to be kicked again.
- message_processor: handle MessageType::Disconnected as a room-level
event before the is_player_address filter (parallel to how
RoomMetadataChanged is handled). The arm in the per-peer match is
now unreachable and noted as such.
- modal_manager / disconnect_handler: replace the manual ColorRect +
Label + Button overlay with the existing Modal scene. New
async_show_session_ended_modal (SIGN IN / EXIT for DuplicateIdentity),
async_show_room_closed_modal and async_show_disconnected_modal
(RECONNECT / EXIT for transient cases). SIGN IN routes through
Global.sign_out() to the lobby, mirroring the Settings logout flow.
- scene_manager: guard physics_process and scene_runner_update with
is_instance_valid() on player_avatar_node. SceneManager is owned by
the DclGlobal autoload and outlives the Explorer scene, so after
change_scene_to_file frees Explorer the cached avatar reference
becomes dangling and physics_process panicked at
player_avatar_node.get_global_position(). Set_player_node reassigns
the field on the next Explorer load so processing resumes normally.
* fix: AvatarModifierArea excludeIds reliably excludes remote avatars (#2166) (#2168)
Three independent gaps caused excludeIds to fail intermittently on mobile:
1. Address casing — profiles delivered over comms carry checksummed eth
addresses while scenes typically push lowercase. Direct `==` comparison
missed those. Extracted into AvatarExcludeIdMatcher with normalization
(lowercase + tolerant 0x prefix).
2. Race on profile arrival — a remote avatar could enter the area before
its profile (and thus avatar_id) was populated, hiding it permanently.
async_update_avatar_from_profile now calls try_show() after avatar_id
updates so the area is re-evaluated.
3. Stale excludeIds on overlapping avatars — when the scene updates
excludeIds after avatars are already inside the area, the setter alone
does not retrigger detection. avatar_modifier_area.rs now invokes
refresh_overlapping_detectors, which iterates Global.avatars (instead
of Area3D.get_overlapping_areas(), whose state is stale mid-CRDT-tick)
and asks each avatar to re-evaluate via try_show().
Regression test: godot/src/test/avatar/test_avatar_modifier_exclude_ids.gd
covers case/prefix mismatches and the empty-avatar_id race.
* fix: line_edit content margin top and bottom (#2176)
* spike: GP profiling benchmark (#1862) — bench-infra base PR for the perf series (#1992)
* feat: Genesis Plaza profiling benchmark (#1862)
Reproducible FPS / frame-time / memory benchmark for Genesis Plaza on
iPhone + Android (min-spec). Triggered by --gp-benchmark CLI flag or
gp-benchmark=true deeplink param; all knobs (durations, toggles, tag,
output path) come from godot/bench/genesis_plaza.config.json or deeplink
overrides (bench-tag, bench-warmup, bench-sample, bench-disable-tweens,
bench-disable-transforms) so the matrix runs without re-exporting.
Sampler captures fps / process+physics frame time / video+texture+buffer
memory / draw calls / render objects / primitives / node + resource +
orphan counts / physics counts / loaded scene count, plus a one-shot
SceneTree walk at end-of-run that emits the top-15 node-type breakdown
and mesh deduplication buckets (RenderingServer / MultiMesh candidates).
Pose is pinned to a hardcoded canonical viewpoint every frame so two
devices render bit-identical screenshots; comms are held throughout so
remote avatars don't appear. Avatar profile is seeded
(randomized_with_seed(1862)) so wearables are stable across runs.
End-of-run writes JSON to user://output/gp-benchmark/, mirrors to
/sdcard/Download/ on Android (release APK can't read user://), saves a
PNG screenshot for pHash sanity-check, dumps summary to stdout one
field per line (logcat truncates long lines), then auto-quits.
Tooling: scripts/bench/launch_devices.sh deeplinks both devices in
parallel and tails logs; profile_android.sh wraps simpleperf to capture
CPU sampling during the sampling phase (PROFILE_WINDOW_BEGIN/END
markers); compare_screenshots.py runs pHash compare against a
reference baseline.
Per-developer config (PREVIEW_HOST, DEVELOPMENT_TEAM, IOS_UDID,
ANDROID_SERIAL) lives in scripts/bench/.env (gitignored, copied from
.env.example) so host IPs and signing identities never land in tracked
files.
* feat(bench): auto-clone + spawn pinned GP preview in launch_devices.sh
Adds `ensure_pinned_preview()` that runs when --gp-benchmark is set and no
--realm is given. Clones genesis_plaza_repo at genesis_plaza_commit (from
godot/bench/genesis_plaza.config.json) into ~/.cache/dcl-bench/Genesis-Plaza-
2025-<short-sha>/, then `npx @dcl/sdk-commands@latest start` from
central-plaza/ on port 8000.
- Idempotent: re-runs reuse the cached checkout; fetch + checkout if
config commit drifts.
- Shared cache outside any worktree (~/.cache/dcl-bench/) so rotating
branches doesn't duplicate the ~150 MB GP repo.
- Reuses any pre-existing localhost:8000 server (user-managed); only
cleans up on EXIT the preview *we* spawned.
- 90 s wait covers the first-run npx install of @dcl/sdk-commands.
Removes the manual "make sure preview is running" step from the
benchmark workflow — `bash scripts/bench/launch_devices.sh --android
--gp-benchmark --pull-results` now works from a clean machine.
* fix(bench): default position=0,0 for gp-benchmark on fresh state
`launch_devices.sh --gp-benchmark` without `--position` was hanging at
"loading 0%" on devices with cleared app state (post `pm clear` or first
install): explorer.gd:262 only overrides `last_parcel_position` when
`cmd_location != Vector2i.ZERO`, and gp_benchmark_runner pins the pose
post-load — so scene_fetcher's first update_position never seeded a
non-zero parcel and the realm fetch never triggered.
Default POSITION to "0,0" when --gp-benchmark is set and no explicit
--position was passed. Matches the canonical bench pose at parcel (0,0)
and unblocks fresh-state runs.
Repro: `pm clear org.decentraland.godotexplorer; launch_devices.sh
--android --gp-benchmark` → hung. With this fix → loads and benches.
* perf(bench): symbol-rich profiling helpers
- scripts/bench/build_for_profile.sh: builds an Android APK that pairs
Rust dev-release (optimized + debug info) with the Godot DEBUG export
template (libgodot_android.so unstripped + GNU build-id). The default
--release path uses the release template, which simpleperf cannot
symbolicate inside Godot internals (no build-id, stripped). Use only
for profiling sessions; the debug template is slower than release, so
not for FPS A/B.
- scripts/bench/profile_android.sh: after pulling perf.data, run the
NDK binary_cache_builder.py against the Rust target dir so the
resulting report_html.py / inferno output resolves libdclgodot.so
offsets to demangled function names. With build_for_profile.sh,
libgodot_android.so symbols also resolve (matching build-id).
Closes the feedback loop on the Step 4 spike: V8 internals like
v8::internal::Heap::NotifyObjectLayoutChange / Builtins_LoadIC_Noninlined
showed up in the resymbolicated profile and confirmed the V8/Deno scene
tick is the Android FPS cap, not draws.
* feat(bench): per-state CPU + CRDT throughput instrumentation (gated)
Adds per-frame measurement plumbing for Genesis Plaza profiling:
- update_scene.rs: STATE_TIMING global Mutex<HashMap> records µs+count per
SceneUpdateState. Gated by STATE_TIMING_ENABLED AtomicBool because the
per-state lock-acquire is hit ~30× per scene per tick — measured 50 %
FPS regression on GP when always-on (~70 scene threads contend on the
same global mutex).
- engine.rs: send/recv byte + dirty-entry counters (atomics, cheap), plus
per-component-id dirty breakdown (Mutex<HashMap>, gated by
CRDT_BREAKDOWN_ENABLED). The breakdown identifies which SDK7 components
dominate the V8↔Rust round-trip — initial GP read showed TweenState 51 %
of dirty_lww_entries, Transform 49 %.
- scene_manager.rs: #[func]s `reset_state_timing`, `drain_state_timing`,
`reset_crdt_metrics`, `drain_crdt_metrics`, `drain_crdt_component_breakdown`
surface the data to GDScript. The reset variants flip the gating bools on;
the drain variants flip them off so post-sample work doesn't pollute the
next window.
- gp_benchmark_runner.gd: resets at warmup→sampling boundary, drains at
sampling→done, embeds in result JSON. Also reorders `_save_screenshot`
to run AFTER `END_RESULT_JSON` is printed and the public-path mirror
is written — the synchronous PNG encode (~200-500 ms zlib on 1080p)
was contaminating profile windows and risked losing JSON if the
screenshot failed.
Bench script wiring:
- scripts/bench/launch_devices.sh: `--profile` flag spawns the matching
`profile_*.sh` per device in parallel. Each profiler watches its
device log for the `PROFILE_WINDOW_BEGIN duration_s=<N>` marker emitted
by the bench runner and captures simpleperf (Android) / xctrace (iOS)
for that exact window.
- scripts/bench/profile_android.sh: extracts unstripped libgodot_android.so
from the installed APK before symbolicating, so VkThread offsets
resolve to function names via simpleperf's binary_cache_builder.
- scripts/bench/profile_ios.sh (NEW): xctrace wrapper symmetric to the
Android one. Watches /tmp/dcl-bench-ios.log for PROFILE_WINDOW_BEGIN,
records a Time Profiler trace for the same duration, archives to
bench-results/profiles/ios-<tag>-<timestamp>/.
* feat(debug): --inspect-scene-title + render-manager getter wiring
Three small bench/debug utilities used by the GP profiling work but
that don't fit the perf-fix or instrumentation commits:
- dcl_cli.rs / scene_fetcher.gd: --inspect-scene-title <title> (CLI
flag) + inspect-scene-title=<title> (deeplink param). With the
`enable_inspector` build feature on, the SDK7 scene whose title
matches attaches a Chrome DevTools-compatible V8 inspector on
127.0.0.1:9222. Title-match (instead of opaque scene id) so
benchmarks can pick the right isolate from a deeplink without
having to discover the id beforehand. Deeplink wins over CLI when
both are set so a single APK can run different traces.
- dcl_cli.rs: --rs-gltf-direct flag for the RenderingServer-direct
GLTF path (default OFF, see GLTF→RS migration spike). Surfaced
here so it can be flipped per-run without rebuilding.
- godot_classes/mod.rs: wire up `dcl_gltf_render_manager` (referenced
by Global.get_gltf_render_manager() under the rs-gltf-direct flag).
- gltf_container.gd / global.gd: cleanup of an earlier frustum-cull
animation coordinator that didn't pan out (process_mode toggling
on AnimationPlayer at 3 Hz across 100+ nodes regressed FPS more
than the work it skipped saved). The render-manager getter and
RS-direct slot release in `_exit_tree` stay; the
AnimationCoordinator references are removed.
- gltf_container.tscn: the per-container Timer was already replaced
by Global.get_gltf_load_timeout_coalescer(); this drops the now-
dangling [node] entry.
* feat(bench): GPU render time + merge classifier + textureless prototype + design doc
Adds bench-side instrumentation that finally pins down GP's GPU bottleneck:
- Per-sample render_cpu_ms / render_gpu_ms via RenderingServer's Vulkan
timestamp queries. Confirms GP is GPU-bound: render_gpu_ms 50 ms, render_cpu_ms 6 ms.
- viewport-scale-3d deeplink param. A/B at 1.0 vs 0.75 showed only -5% GPU
time -> not fill-rate bound, it's draw-call / vertex submission.
- force-graphic-profile + uncap_fps deeplink params for repeatable A/B.
- Mesh merge classifier (_classify_mesh_mergeable + _merge_buckets +
_unique_materials + _merge_skipped). On GP: 2947 mergeable / 992 unique
materials. Top bucket: 1016 textureless meshes.
- Textureless merge GDScript prototype with 32 m spatial-cell partition.
Three iterations (queue_free / visible=false / layers=0) all regressed
FPS — DCL scene_runner overrides per-frame, queue_free races -> SIGABRT.
Lessons captured in the design doc.
docs/bench/material-atlas-mesh-merge-design.md captures the full plan
(material atlas, vertex-id bucketing, runtime promote/demote with
PromotionTracker, RAM strategies A/B/C, phasing) and the prototype's
verdict: the merge has to live inside the Rust scene_runner — it cannot
be retrofitted from outside.
* feat(bench): per-feature graphics overrides via deeplink params
Adds gfx-aa/shadow/bloom/skybox/texture deeplink params that override the
forced graphic profile post-load, isolating each feature's GPU cost.
Used to identify Genesis Plaza fragment-bound contributors on Mali-G68
(Samsung A54). With Medium profile + mesh-lod=8.0 baseline at 56ms gpu:
- shadow=0 → −10ms gpu, −535 draws
- bloom=0 → −10ms gpu (pure fragment)
- aa=0 → −8ms gpu (TAA fragment)
- skybox=0 → −8ms gpu (cubemap fragment)
Each variant reproducible via --param gfx-shadow=0 etc.
* feat(bench): add --profile-gpu (AGI perfetto Mali capture, partial)
Mirrors --profile (simpleperf CPU) for GPU. Adds:
- scripts/bench/profile_android_gpu.sh — wrapper that wipes the sticky
gpu_debug_* settings (sticky from prior `gapit trace -api vulkan`),
activates the Mali libgpudataproducer.so via gapit
validate_gpu_profiling, then watches /tmp/dcl-bench-android.log for
the bench's PROFILE_WINDOW_BEGIN marker and runs `perfetto -c <cfg>`
for the duration_s emitted by gp_benchmark_runner.gd.
- scripts/bench/perfetto_gpu.cfg — gpu.renderstages + gpu.counters +
ftrace config.
- launch_devices.sh: new --profile-gpu flag that spawns the script in
parallel with the bench.
Status: scaffolding works (config + script + integration), but the
Mali producer dies when gapis exits, leaving the perfetto trace empty
of gpu.renderstages data unless gapis is kept alive throughout the
capture window. Needs follow-up to either:
a) keep gapis running in background for the bench duration, or
b) switch to `gapit trace -api perfetto -uri <pkg>/<activity>` flow
(which keeps gapis alive automatically but launches the app fresh
and can't accept the deeplink params the bench needs).
Useful data on Mali GPU activity is still capturable manually via
`adb shell perfetto -c cfg --txt -o ...` while a `gapit trace` is
already running on the device.
* feat(bench): force-graphic-profile + DG/HW-bench disable + per-feature deeplink overrides
Extends gp_benchmark_runner with deeplink-routed overrides (force-graphic-profile, mesh-lod-threshold, viewport-scale-3d, shadow-mesh, visibility-grid, cheap-pbr, only-no-optimized) and the DG/HW-bench disable dance during warmup (param_changed + thermal_fps_cap_changed disconnects, Engine.max_fps=0, vsync DISABLED, viewport_set_measure_render_time). Also strips stale mod refs to downstream-only crates that leaked through earlier cherry-picks.
* feat(bench): visibility-grid V4 (opt-in via deeplink)
Cell-based PVS culler for the GP benchmark substrate. Built once after loading_complete; static MeshInstance3Ds bucketed into every cell their world AABB overlaps (multi-cell membership for diag >= 5m avoids the big-building-disappears artifact). Temporal hysteresis HIDE_DELAY_FRAMES=60 prevents popping at cell boundaries.
Gated by config["visibility_grid"] from deeplink param visibility-grid=true. NOT enabled in any graphic profile — strictly a benchmark / diagnostic tool. Wins -3ms on LOW profile but neutral-to-regress on HIGH (the cell-scan CPU cost can match the GPU saving on already-fragment-bound builds).
Re-derives e9e2737a + be7430d9 from feat/performance-maximizing.
* feat(bench): analyze_bench.py + launch_with_remote_debug.sh
analyze_bench.py: side-by-side delta table for any number of bench JSONs (mean / p50 / p95 columns), Markdown output for PR bodies.
launch_with_remote_debug.sh: helper for debug-template runs only. Release builds do NOT receive --remote-debug; godot/export_presets.cfg keeps command_line/extra_args="".
* docs(bench): post-async-png profile analysis + RAM hog inventory
Captures the deep profile findings from the May 2026 session: per-state CPU breakdown, CRDT throughput, top-N RAM consumers on Android, GPU vs CPU bound classification on A54 Mali-G68.
* feat(bench): plumbing crates required by bench instrumentation
Restore rs-gltf-direct module (dcl_gltf_render_manager + gltf_render/) and gltf_load_timeout_coalescer GDScript. Both are wired through global.gd and gltf_container.gd by the cherry-picked bench-runner / scene-runner instrumentation commits. The rs-gltf-direct path is OFF by default (CLI flag --rs-gltf-direct opts in) so this only fixes GDScript parser type-resolution; runtime behavior is unchanged.
Without these files the GDScript parser fails to resolve 'DclGltfRenderManager' and the autoload chain dies — Godot prints 'Failed to instantiate an autoload, script global.gd does not inherit from Node'.
* feat(bench): add load_seconds + process_rss_mb + render_*_ms summary keys
- load_seconds: time from `waiting_for_load` phase start until scene_runner.loading_complete fires. Surfaced as top-level JSON key so downstream PRs can A/B cache-warm load time.
- process_rss_mb (per-sample): reads /proc/self/status VmRSS to capture the FULL Android process footprint, not just Godot's tracked heap. Falls back to 0 on non-Linux/Android.
- render_cpu_ms / render_gpu_ms / render_objects_in_frame: added to summary keys list — the per-sample values were captured but never aggregated (summary.render_gpu_ms.mean was null in every prior bench).
Baseline-high.json on spike tip (A54, GP preview, force-graphic-profile=3, cold cache, no perf wins): load_seconds=26.28, fps.mean=7.14, render_gpu_ms.mean=95.33, render_cpu_ms.mean=7.35, video_mem_mb=778, prims=2.6M, draws=1896.
* style: gdformat post-merge drift
* style: gdlint disable class-definitions-order on visibility_grid
* chore(spike): strip optimization features — keep only bench infra
Removes from spike (move to dedicated PRs or experiments):
- rs-gltf-direct module (DclGltfRenderManager + gltf_render/) — opt-in feature
- gltf_load_timeout_coalescer — perf fix (replaces 1419 Timers in GP)
- Textureless merger GDScript prototype + classifier (gp_benchmark_runner.gd) — experiment
- 9 zombie Global.scene_runner.drain_*_stats() refs (textureless/material_atlas/
mesh_lod/auto_distance_cull/occluder_gen/asset_preproc/auto_shadow_cull/cheap_pbr) —
referenced experiment Rust modules that don't exist on spike
- Visibility grid V4 GDScript (godot/src/tools/visibility_grid.gd) — moves to #2037
- Deeplink params for non-existent CLI flags (textureless-merge, material-atlas,
mesh-lod, auto-distance-cull, occluder-gen, asset-preproc, auto-shadow-cull,
cheap-pbr, shadow-mesh, skip-gltf, kill-sky, visibility-grid)
- _purge_existing_gltfs / _purge_existing_skies bench utilities (only invoked
via removed skip-gltf / kill-sky flags)
Spike now contains only: GP benchmark runner, bench scripts, bench-time CRDT
+ per-state-CPU instrumentation (gated), force-graphic-profile, fixed-skybox-
time, --inspect-scene-title, --gp-benchmark CLI flags. No optimization code.
* chore(spike): drop stale profile + design docs
profile-deep-2026-05.md: timestamped deep-profile analysis, snapshot of
state from 2026-05. Superseded by the per-PR bench numbers in the
extracted perf stack.
material-atlas-mesh-merge-design.md: design doc for rejected experiment,
moved to feat/performance-experiments branch.
* chore(spike): drop stale session log
session-2026-05-07-summary.md was a timestamped journal of the perf work;
the wins now live in their own PRs (#2034 cheap-pbr, #2035 paired-shadow,
#2037 vgrid, #2050 coalescer, #2051 rs-gltf-direct, #2053 crdt-recv-split,
#2054 async-png+tween-dedup).
* feat(bench): gate warmup on DclGltfContainer settling
`scene_runner.loading_complete` fires when the scene-runner declares
the scene-set loaded, but individual SDK7 DclGltfContainer children may
still be streaming their GLBs. Without a settling gate, the warmup
window can race the slowest GLB (e.g. DCL Store in GP) and the sample-
time screenshot captures placeholder cubes — the bench JSON numbers
were trustworthy (sampling starts after warmup), but the screenshot
output was unreliable.
Adds a 'settling' phase between 'waiting_for_load' and 'warmup' that
polls DclGltfContainer.dcl_gltf_loading_state across the tree. Advances
to warmup once all containers are in a terminal state (FINISHED /
NOT_FOUND / FINISHED_WITH_ERROR) — or after the
'settling_timeout_seconds' cap (default 60s) with a warning log.
* fix(bench): stop re-setting vsync per-frame in sampling phase
On Mali/Swappy the Vulkan driver rejects VSYNC_DISABLED and falls back
to ENABLED. The per-frame "insurance" set triggered swap_chain_resize
every frame (~10 rebuilds/sec during sampling), thrashing the renderer
thread with Vulkan framebuffer reallocs, shader pipeline rebuilds via
respv::Shader::sort, scudo allocator bursts and atomic CAS cascades.
This self-inflicted churn was masquerading as a "CPU bottleneck" in
profiles and halved the measured FPS on A54. Vsync/max_fps are already
set once at the waiting_for_load -> settling transition; that is
sufficient.
Bench A/B on A54 HIGH profile, bench/full-stack (pinned pose):
| metric | pre-fix | post-fix | delta |
|-----------------------------|--------:|---------:|-------:|
| fps mean | 9.65 | 19.30 | +100% |
| fps p50 | 8 | 19 | +137% |
| frame_time_process_ms p50 | 117ms | 41ms | -65% |
| frame_time_process_ms mean | 129ms | 55ms | -57% |
| render_gpu_ms p50 | 47ms | 48ms | ~same |
| draw_calls p50 | 1894 | 1884 | ~same |
All earlier perf-stack A/B numbers were taken with the bench thrashing
itself; they should be re-measured against this corrected substrate.
* feat(bench): --bench-mode CLI flag skips HardwareBenchmark + DynamicGraphicsManager
Bench substrate fix. Two startup subsystems were interfering with the
GP benchmark runner:
1) `HardwareBenchmark` (lobby.gd:308 → global.gd:_run_first_launch_benchmark)
calls `RenderingServer.viewport_set_measure_render_time(rid, true)` at
start, then disables it after measuring. With `pm clear` between runs the
`first_launch_completed` flag is always false, so the HW bench fires every
bench run and its trailing `set_measure_render_time(rid, false)` races the
bench runner's `set(rid, true)` at settling → `render_gpu_ms` came back as
0 on some branches.
2) `DynamicGraphicsManager` initializes via deferred call in Global._ready,
acquires the viewport, enables render-time measurement, and queues
thermal-cap signals — all before the bench runner has a chance to
`set_enabled(false)` it. Adds ~200ms of startup CPU noise and competes
for the same measurement state.
`bench_mode`:
- Auto-enabled when `--gp-benchmark` CLI flag is passed (desktop)
- Or explicit `--bench-mode` flag
- Or `gp-benchmark=true` deeplink param: deep_link_router.gd flips
`Global.cli.bench_mode = true` BEFORE spawning the runner. Timing
observed on A54: deeplink fires at 07.447ms; DG's deferred init at
07.705ms; lobby's HW-bench trigger at 08.476ms — both honor the flag.
`should_run_first_launch_benchmark()` returns false in bench_mode.
`_init_dynamic_graphics_manager()` early-returns in bench_mode.
Expect: cleaner perf profiles (no HW-bench-and-DG churn on VkThread
during startup → bleeds into early samples on cold runs), and stable
`render_gpu_ms` measurements across branches.
* fix(bench): settling requires non-zero-then-zero, not just zero
`_count_loading_gltf_containers()` returns 0 in two scenarios:
1) Loading actually finished (all in terminal state) — intended success.
2) Loading hasn't started yet — no DclGltfContainer Godot wrappers
exist in the scene tree.
Scenario (2) is the race we kept hitting. The Rust scene_runner emits
`loading_complete` at the moment its internal session is done, but the
GDScript-side wrappers for those entities haven't been instantiated yet
(their _ready propagates in subsequent ticks). The naive "still_loading
== 0" check bailed in ~414ms with no containers ever counted, then
warmup arrived with the scene still half-spawned and sampling captured
a moving target.
Effect on previously published numbers:
- bench/baseline registered fps=25 / draws=867 / render_gpu_ms=null on
what was actually a half-loaded scene (user reported "se veía como
seguía cargando" during sampling).
- bench/full-stack registered fps=9.65 / draws=1894 because its loader
optimizations happened to push further along the same 30s warmup
window; not a real perf delta over baseline.
New gate:
- Require `_settling_saw_loading` (peak count > 0 observed at some
point) before treating 0-count as "done".
- Add `settling_min_seconds` floor (default 5s) so brief 0-dips before
late spawns don't end settling.
- Track `_settling_peak_loading` for the log message + timeout warning.
- Timeout (60s) still advances regardless.
Expect: longer settling on cold runs (real measurement of when scenes
are actually instantiated), more representative warmup/sampling
windows, and comparable A/B between branches that load at different
rates.
* feat: apply PBPhysicsCombinedImpulse/Force to player (#1537) (#2140)
* feat: apply PBPhysicsCombinedImpulse/Force to player (#1537)
Adds renderer support for the two SDK7 protocol components that let scenes
drive physics on the player: a one-shot impulse (event_id-deduplicated) and
a continuous force. Mirrors the unity-explorer SDKExternalPhysicsSystems
behavior; only the current parcel scene may apply, and forces clear on
scene exit. The player controller applies the queued impulses and active
force in _physics_process using CHARACTER_MASS = 0.8 to match Unity feel,
with the upward-impulse-cancels-gravity rule.
* refactor(player): mirror Unity ExternalVelocity model for combined physics
The first pass added impulse and force directly onto CharacterBody3D.velocity,
which silently dropped almost all horizontal effect: line 462-463 resets
velocity.x/z to input×speed every tick, so accumulated forces and sideways
impulses lived for one frame and disappeared. CHARACTER_MASS was also 0.8
versus Unity's actual 1.0.
This rewrites _apply_scene_physics around a persistent external_velocity
vector matching unity-explorer:
- Force XZ accumulates into external_velocity (Unity ApplyExternalForce).
- Force Y feeds the gravity step via effective_gravity = gravity − accel.y
(Unity ApplyGravity), so wind tunnels cancel gravity instead of stacking
upward velocity on the body.
- Impulses add instant Δv on all three axes (Unity ApplyExternalImpulse).
- Viscous drag every frame: v *= (1 − damping·dt), damping = 1.5 + 4 on
floor, 1.5 in air (Unity ApplyExternalVelocityDragAndClamp).
- Magnitude clamp at 50, snap to zero below 0.01 length.
- external_velocity is mixed into Godot's velocity right before
move_and_slide; the Y contribution is undone afterwards unless a vertical
collision already flattened it, to keep next frame's gravity step honest.
CHARACTER_MASS fixed to 1.0 to match CharacterControllerSettings.asset.
* debug: trace physics_combined decisions for impulse/force
Logs each early-return branch in update_physics_combined_impulse (not-current,
not-dirty, no entry, no value, no vector, event_id dedup hit, queued) and the
force-value transitions in update_physics_combined_force. Lets us pinpoint
which gate is short-circuiting subsequent impulses when debugging a scene.
Filter: --rust-log="dclgodot::scene_runner::components::physics_combined=debug,warn"
* fix: drop event_id dedup for PBPhysicsCombinedImpulse
The first pass treated event_id as the dedup key — every applied impulse
stamped scene.last_impulse_event_id and refused to re-fire on the same id.
godot.log from a live test scene showed exactly one application, then
"dedup hit — event_id=0 already applied" on every subsequent fire. The
test scene (and likely others) leaves event_id at zero between impulses;
it isn't part of the protocol's renderer-side contract.
Unity's SDKExternalPhysicsSystems gates only on the per-CRDT-write IsDirty
flag and never compares event_id — the field exists purely to force CRDT
to propagate a write when the vector is identical. Match that: trust the
dirty signal, drop the comparison, remove the now-unused
Scene.last_impulse_event_id field.
* fix(physics): match godot-explorer change-detection idiom + don't kill impulses on the floor
Two interacting bugs surfaced by the live log: the SDK republishes the
impulse summary every CRDT tick, often with event_id=0 and a static vector,
and the prior implementation got both ends of this wrong.
Rust side: drop the Unity-style "trust IsDirty every write" stance — it's
right for Unity's data model but it stacks impulses here because dirty fires
constantly. Follow the godot-explorer convention used in tween.rs and
video_player.rs: cache the full last-seen (event_id, vector) and only fire
when it changes. Zero vectors are treated as "no impulse" but still update
the cache, so the next non-zero write registers as fresh.
Player side: the unconditional `if on_floor: external_velocity.y = 0` ran
in the same frame the impulse was queued, so any upward impulse applied
while grounded was immediately zeroed. Unity sidesteps this by setting
IsGrounded=false when an upward impulse fires, so its grounded-Y-zero rule
no-ops that tick. Mirror that with an `effective_on_floor` that fresh
upward impulses flip to false for one frame — same effect, fits Godot's
move_and_slide flow without faking is_on_floor().
* fix(physics): honor protocol's event_id dedup for PBPhysicsCombinedImpulse
Per the proto comment: "Renderer processes impulse with the unique ID only
once. Increase eventID of the component to apply another impulse." event_id
is the dedup key. It serves two roles — bypassing CRDT identity-dedup so
identical vectors still propagate, and signalling to the renderer that a
write is a new impulse vs a republish — and the renderer must trust it.
Earlier commits drifted away from this contract: first by treating every
CRDT-dirty signal as a fresh impulse (stacked indefinitely against a
republishing SDK), then by caching the full (event_id, vector) tuple
(stuck after first fire whenever vector was static). Both attempted to
paper over scenes/SDKs that don't increment event_id, which is the SDK's
job. A scene that fails to increment will (correctly) only get the first
impulse; that's an upstream bug to fix, not something the renderer should
guess around.
* fix(physics): fire impulse once per CRDT write, like Unity's IsDirty
Genesis Plaza's bouncePad.ts ships in prod calling
PhysicsCombinedImpulse.createOrReplace(player, { eventId: 0, vector })
on every trigger enter — eventId is hardcoded to 0 forever. It works in
Unity because unity-explorer's SDKExternalPhysicsSystems.ApplyPhysicsImpulse
gates only on its per-write IsDirty flag and never reads event_id. The
proto comment says event_id is the renderer-side dedup key, but the
deployed implementation doesn't enforce that, so real scenes don't bump
it. Matching Unity's behavior is the bar, not the aspirational protocol
text.
Drop the event_id comparison; fire one impulse per CRDT-dirty signal.
That gives a 1:1 mapping with createOrReplace calls, which is what
Genesis-style scenes (and any future scene built against the shipped
contract) expect.
The got-upward-impulse handling in player.gd (commit dffa604) is what
prevents the same-frame on-floor-zero from killing the bounce, so this
revert is safe alongside it.
* fix(player): drive rise/fall anim from external_velocity during scene-driven lift
avatar.rise / avatar.fall are chosen mid-_physics_process from velocity.y
alone, well before _apply_scene_physics runs. Then _apply_scene_physics
writes the lift into external_velocity.y, the mix-and-undo dance pulls it
back out of velocity.y after move_and_slide, and velocity.y starts the
next tick at ~0 again. Net effect on a trampoline: the avatar plays
"idle, fall, idle, fall" while visibly bouncing up — the rise frames
never fire because rise/fall don't see the external lift.
Re-derive the animation flags after _apply_scene_physics using the
combined vertical velocity (velocity.y + external_velocity.y) — same
number that's about to drive move_and_slide. Only kicks in when external
physics is actually doing something, and stays out of glide state.
* chore: tighten comments across the physics_combined patch
Trim the doc/inline comments added during this PR — drop Unity name-drops
where the equivalent didn't add context, collapse multi-paragraph blocks
into one or two lines, and remove restatements of what the code already
says. The two places Unity is still mentioned are load-bearing:
CHARACTER_MASS = 1.0 for scene-tuning parity, and the impulse handler doc
that explains why we ignore event_id despite the proto's claim.
* fix(player): restore locomotion XZ after move so external_velocity doesn't compound
Snapshot velocity.x/z before adding external_velocity and restore them after
move_and_slide. Without this, the next no-input tick's move_toward decelerates
from velocity-with-external stacked, while external_velocity is re-added each
frame — net acceleration of ~(ext - walk_speed) per frame, unbounded until a
wall stops you.
Matches Unity's effective behavior (MovementVelocity / GravityVelocity /
ExternalVelocity tracked as separate vectors, summed only at move time).
* fix: handle PhysicsCombined* variants in SceneUpdateState match
The PhysicsCombinedForce/Impulse variants added in #2140 were not
covered by the exhaustive state_name() match, breaking the build on main.
* feat(settings): add gamepad mode toggle (default off) (#2169)
* disable: automatic gamepad mode switching
Disable the automatic gamepad detection system that hides virtual controls
when a physical gamepad is connected. The code is left in place (commented out)
with TODO markers for the future implementation: a dialog that asks the user
to explicitly enter gamepad mode instead of switching automatically.
https://claude.ai/code/session_01BmntyURBCUnX4oo4hZv9fu
* feat(settings): add gamepad mode toggle (default off)
Replace the hardcoded gamepad disable with a persisted setting in
Settings > Gameplay > Gamepad. When enabled and a physical gamepad is
connected, virtual controls hide and gamepad tooltips/sensitivity appear.
When disabled (default), the gamepad is never detected.
* fix: use explicit type annotations for GDScript type inference
* chore
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(sentry): attach user, context, tags, and dist to events (#2157)
* feat(sentry): attach user, context, tags, and dist to events
Sentry events were arriving with `user.id=null` (a SentryUser was built
in global.gd then dropped without calling set_user), no contexts, and
only build-identity tags. Triage was hard because events couldn't be
attributed to an install or filtered by platform/realm/auth state.
- Restore the missing `SentrySDK.set_user(...)` call; keep id pinned to
`analytics_user_id` across login/logout so "Users affected" stays
attributed to a single install. Username tracks wallet address and
display name when known.
- Set `options.dist = "<platform>-<commit_hash>"` so cross-platform CI
runs of the same release no longer collide.
- Add static tags (platform, build_type, gpu_vendor, locale) and a
`graphics` context at init time.
- Refresh `realm` / `location` contexts and `realm` / `comms_adapter` /
`is_guest` tags from existing signals (realm_changed,
player_parcel_changed, on_adapter_changed, wallet_connected,
profile_changed, logout). All wiring is gated by the existing
`telemetry_enabled` check.
- Pin `send_default_pii = false` to lock in current behavior across
SDK upgrades.
* refactor(sentry): extract runtime seeding into SentrySeeder
Moves the user/context/tag wiring out of global.gd into a dedicated
RefCounted class, mirroring the AnalyticsController pattern. global.gd
now just instantiates SentrySeeder and calls setup(); the six signal
handlers and the initial set_user block live in sentry_seeder.gd.
No behavior change — the SDK init and static (process-lifetime) tags
stay in project_main_loop.gd as before.
* feat: scene/UI/avatar/app-UI inspection WebSocket server (#2181)
* debug server wip
* docs, format fixes
* fix server hangin on large responses
* creating debug-ws-inspector skill
* chore: update Node.js to 24 (#2186)
Upgrade all CI workflow and Dockerfile Node.js version references to Node 24 (LTS).
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* iOS IAP: Credits purchase flow via StoreKit (#2099)
* setup
* iap test
* wip
* revert
* remove
* wip
* test
* fix
* wip
* wip
* remove noise
* wip
* add success, failure and limit reached modals, add faqs, improve layout
* move IAP UI components to atomic design paths
* add credits history, balance card, skeleton loading and purchase error modals
- Add History button in credits section with transaction history view
- Add credits_balance_card molecule showing current balance (synced via Iap.balance_changed)
- Add credits_history_item molecule for purchase/refund entries
- Add credits_history organism that loads existing history and listens for new transactions
- Add skeleton loading states for credits options and FAQ sections
- Add purchase failed modal (Something went wrong) on failure and cancellation
- Add credit limit reached modal when exceeding max credits (115, mocked)
- Add transaction_history_updated signal to Iap for reactive UI updates
- History button only visible when there are transactions
- Editor overrides for testing IAP flow on desktop (marked with TODO: remove)
* extract CreditsShop organism from Discover page
Move skeletons, credit options and FAQ into a self-contained CreditsShop
organism that listens to Iap signals internally. Discover now only
handles navigation and visibility.
* fix scroll passthrough on FAQ buttons and credit option items
* fix mouse filter and button styles in credits shop and discover
* fix gdformat lint errors
* remove dead iap dev panel, fix signal disconnects, revert viewport settings
* fix overlay timeout allowing double purchases
* fix gdlint errors in iap files
* fix StoreKit callbacks running off main thread
* move IAP terms modal to buy button, add daily credit limit
* fix async function naming for gdlint
* fix refund display in credits history
* make iap test accesible from deeplink decentraland://open?iap_enabled=true
* fix
* update plugin
* test
* clean up
* review applied
* apply review
---------
Co-authored-by: Sebatián Di Lauro <jsdilauro@gmail.com>
Co-authored-by: Sebastián Di Lauro <seba@dclregenesislabs.xyz>
* chore: remove gamepad feature and settings (#2193)
Rip out the gamepad mode feature (introduced in #2169) along with all
underlying gamepad support:
- Remove the gamepad_mode_enabled and gamepad_camera_sensitivity config
values (and the GAMEPAD_CAMERA_SENSITIVITY param) from config_data.gd
- Remove the Gamepad settings section from the settings page (the
"Enable Gamepad Mode" toggle and the camera sensitivity slider) plus
the associated signal handlers
- Remove gamepad detection / virtual-control hiding from explorer.gd;
virtual controls now always show on mobile
- Delete PlayerGamepadInput and stop attaching it in player.gd
- Remove gamepad button/joystick hints from the pointer tooltip
- Delete the now-unused gamepad icon assets
* fix: TextShape font_auto_size to match Unity client (#2178)
* font auto size implementation, outline changee
* text size adjustment
* improving code
* text_shape: extract Unity↔Godot conversion helpers, fix font on update
- Drop DCL_TMP_SIZE_FACTOR from UNITY_TMP_FONT_SIZE_MIN/MAX so the
17/18 correction is applied exactly once via TMP_TO_LABEL3D_FONT_SIZE.
- Introduce unity_to_godot_font_size / unity_to_godot_outline_size so
call sites read as named conversions instead of inline arithmetic.
- Move label_3d.set_font outside the add_to_base branch so runtime font
changes on existing TextShape entities actually reach the label.
* fix: navbar toggle stealing focus disables mobile movement (#2198)
* adding focus debug to server
* fix focus
* fix: avatar impostors freeze instead of throttling near screen edges (#2192)
The off-screen animation freeze cleared the throttle flag without resetting the
AnimationTree callback mode, leaving it in MANUAL with nothing to advance it;
the per-frame re-activation then forced active=true. Avatars the LOD
coordinator's approximate (6-frame-lagged) frustum test flagged off-screen but
that Godot still drew — at screen edges or during camera pans — got stuck on a
frozen pose instead of throttling.
- Drive the freeze off the avatar's real on-screen state
(VisibleOnScreenNotifier3D) instead of the coordinator's approximate flag, so
freeze <-> actually not drawn.
- Route every throttle change through AvatarLODHelpers.set_animation_throttle so
callback mode and the throttle flag move together; the freeze now resets to
IDLE, so a stray re-activation animates normally instead of freezing.
- Centralize the animation drive in AvatarLODHelpers
(resolve_anim_drive / apply_screen_freeze / ensure_anim_active).
Adds a headless regression test (test_avatar_anim_throttle.gd) pinning the
invariant: an on-screen avatar in FULL/MID/CROSSFADE is never left
active+MANUAL+throttle-off (frozen while drawn). Verified it fails on the old
behavior and passes on the fix.
* ci: publish Android APK to R2 + unified mobile build/distribute trigger (#2199)
* ci: publish Android APK to R2 + unify mobile build/distribute trigger
- android_builds.yml: upload the signed APK to the R2 mobile-artifacts
bucket (per-commit + latest), and link it directly from the PR build
report. Skips cleanly when R2 secrets are absent (forks).
- New mobile_distribute.yml: single 'build' label (alias 'build-ios') /
manual dispatch that ships iOS to TestFlight and, once the commit's
APK is in R2, posts a Slack '🤖 Android Build Ready' notification +
PR comment with the download link. No rebuild, no store push.
- Replaces ios_label_trigger.yml (folded in; reacts to build/build-ios).
- Drop the 'VR and Mobile' naming from the Android build (Android only).
- Docs (CLAUDE.md/README.md/REVIEW.md) updated to match.
Required repo secrets: MOBILE_ARTIFACTS_R2_{ACCESS_KEY_ID,SECRET_ACCESS_KEY,
ENDPOINT,BUCKET}; reuses existing SLACK_WEBHOOK_URL.
* ci(ios): add TestFlight 'What to Test' notes for Xcode Cloud publish
Write TestFlight/WhatToTest.en-US.txt next to the exported Xcode project so
Xcode Cloud auto-attaches it as the build description on TestFlight.
- Optional whats_new input on ios_builds.yml + mobile_distribute.yml for a
manual override; passed via env to avoid shell injection.
- Falls back to auto-generated notes (branch, commit, PR, actor, last commit
message) when not provided (e.g. PR-label triggers).
- Truncated to TestFlight's 4000-char limit.
* ci: publish AAB to R2 + Slack on manual main/release dispatch
- android_builds.yml: also upload the signed AAB to R2 (per-commit +
latest), gated to main/release builds only (APK stays every build).
- mobile_distribute.yml: on workflow_dispatch of main/release, wait for
the AAB in R2 (best-effort) and post a '📦 Android AAB Ready' Slack
notification with a Download AAB button. PR-label triggers are
unaffected (APK only).
- Docs updated.
* ci(android): build AAB + upload debug symbols only on main/release
Skip the wasteful per-PR work:
- Gate AAB export/sign/artifact-upload to main/release (PRs build APK only).
- Gate the debug-symbols artifact upload and Sentry symbol upload to
main/release.
- PR build report shows AAB/Debug Symbols as 'main/release builds only'
instead of dead artifact links.
The R2 AAB upload was already main/release-gated; this aligns the build
steps so the artifact is no longer produced on PRs.
* feat: auto-trigger mobile distribution on release push + daily main cron
mobile_distribute.yml now runs automatically, not just on the build label
or manual dispatch:
- push to `release…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Changes
Closes
#2185
🤖 Created via Slack with Claude
Requested by Lautaro Petaccio via Slack